// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package io.flutter.plugin.platform;

import static android.view.MotionEvent.PointerCoords;
import static android.view.MotionEvent.PointerProperties;

import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.UiThread;
import androidx.annotation.VisibleForTesting;
import io.flutter.embedding.engine.dart.DartExecutor;
import io.flutter.embedding.engine.systemchannels.PlatformViewsChannel;
import io.flutter.plugin.editing.TextInputPlugin;
import io.flutter.view.AccessibilityBridge;
import io.flutter.view.TextureRegistry;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

/**
 * Manages platform views.
 *
 * <p>Each {@link io.flutter.app.FlutterPluginRegistry} has a single platform views controller. A
 * platform views controller can be attached to at most one Flutter view.
 */
public class PlatformViewsController implements PlatformViewsAccessibilityDelegate {
  private static final String TAG = "PlatformViewsController";

  // API level 20 is required for VirtualDisplay#setSurface which we use when resizing a platform
  // view.
  private static final int MINIMAL_SDK = Build.VERSION_CODES.KITKAT_WATCH;

  private final PlatformViewRegistryImpl registry;

  // The context of the Activity or Fragment hosting the render target for the Flutter engine.
  private Context context;

  // The View currently rendering the Flutter UI associated with these platform views.
  private View flutterView;

  // The texture registry maintaining the textures into which the embedded views will be rendered.
  private TextureRegistry textureRegistry;

  private TextInputPlugin textInputPlugin;

  // The system channel used to communicate with the framework about platform views.
  private PlatformViewsChannel platformViewsChannel;

  // The accessibility bridge to which accessibility events form the platform views will be
  // dispatched.
  private final AccessibilityEventsDelegate accessibilityEventsDelegate;

  // TODO(mattcarroll): Refactor overall platform views to facilitate testing and then make
  // this private. This is visible as a hack to facilitate testing. This was deemed the least
  // bad option at the time of writing.
  @VisibleForTesting /* package */ final HashMap<Integer, VirtualDisplayController> vdControllers;

  // Maps a virtual display's context to the platform view hosted in this virtual display.
  // Since each virtual display has it's unique context this allows associating any view with the
  // platform view that
  // it is associated with(e.g if a platform view creates other views in the same virtual display.
  private final HashMap<Context, View> contextToPlatformView;

  private final PlatformViewsChannel.PlatformViewsHandler channelHandler =
      new PlatformViewsChannel.PlatformViewsHandler() {
        @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
        @Override
        public long createPlatformView(
            @NonNull PlatformViewsChannel.PlatformViewCreationRequest request) {
          ensureValidAndroidVersion();

          if (!validateDirection(request.direction)) {
            throw new IllegalStateException(
                "Trying to create a view with unknown direction value: "
                    + request.direction
                    + "(view id: "
                    + request.viewId
                    + ")");
          }

          if (vdControllers.containsKey(request.viewId)) {
            throw new IllegalStateException(
                "Trying to create an already created platform view, view id: " + request.viewId);
          }

          PlatformViewFactory viewFactory = registry.getFactory(request.viewType);
          if (viewFactory == null) {
            throw new IllegalStateException(
                "Trying to create a platform view of unregistered type: " + request.viewType);
          }

          Object createParams = null;
          if (request.params != null) {
            createParams = viewFactory.getCreateArgsCodec().decodeMessage(request.params);
          }

          int physicalWidth = toPhysicalPixels(request.logicalWidth);
          int physicalHeight = toPhysicalPixels(request.logicalHeight);
          validateVirtualDisplayDimensions(physicalWidth, physicalHeight);

          TextureRegistry.SurfaceTextureEntry textureEntry = textureRegistry.createSurfaceTexture();
          VirtualDisplayController vdController =
              VirtualDisplayController.create(
                  context,
                  accessibilityEventsDelegate,
                  viewFactory,
                  textureEntry,
                  physicalWidth,
                  physicalHeight,
                  request.viewId,
                  createParams,
                  (view, hasFocus) -> {
                    if (hasFocus) {
                      platformViewsChannel.invokeViewFocused(request.viewId);
                    }
                  });

          if (vdController == null) {
            throw new IllegalStateException(
                "Failed creating virtual display for a "
                    + request.viewType
                    + " with id: "
                    + request.viewId);
          }

          // If our FlutterEngine is already attached to a Flutter UI, provide that Android
          // View to this new platform view.
          if (flutterView != null) {
            vdController.onFlutterViewAttached(flutterView);
          }

          vdControllers.put(request.viewId, vdController);
          View platformView = vdController.getView();
          platformView.setLayoutDirection(request.direction);
          contextToPlatformView.put(platformView.getContext(), platformView);

          // TODO(amirh): copy accessibility nodes to the FlutterView's accessibility tree.

          return textureEntry.id();
        }

        @Override
        public void disposePlatformView(int viewId) {
          ensureValidAndroidVersion();

          VirtualDisplayController vdController = vdControllers.get(viewId);
          if (vdController == null) {
            throw new IllegalStateException(
                "Trying to dispose a platform view with unknown id: " + viewId);
          }

          if (textInputPlugin != null) {
            textInputPlugin.clearPlatformViewClient(viewId);
          }

          contextToPlatformView.remove(vdController.getView().getContext());
          vdController.dispose();
          vdControllers.remove(viewId);
        }

        @Override
        public void resizePlatformView(
            @NonNull PlatformViewsChannel.PlatformViewResizeRequest request,
            @NonNull Runnable onComplete) {
          ensureValidAndroidVersion();

          final VirtualDisplayController vdController = vdControllers.get(request.viewId);
          if (vdController == null) {
            throw new IllegalStateException(
                "Trying to resize a platform view with unknown id: " + request.viewId);
          }

          int physicalWidth = toPhysicalPixels(request.newLogicalWidth);
          int physicalHeight = toPhysicalPixels(request.newLogicalHeight);
          validateVirtualDisplayDimensions(physicalWidth, physicalHeight);

          // Resizing involved moving the platform view to a new virtual display. Doing so
          // potentially results in losing an active input connection. To make sure we preserve
          // the input connection when resizing we lock it here and unlock after the resize is
          // complete.
          lockInputConnection(vdController);
          vdController.resize(
              physicalWidth,
              physicalHeight,
              new Runnable() {
                @Override
                public void run() {
                  unlockInputConnection(vdController);
                  onComplete.run();
                }
              });
        }

        @Override
        public void onTouch(@NonNull PlatformViewsChannel.PlatformViewTouch touch) {
          ensureValidAndroidVersion();

          float density = context.getResources().getDisplayMetrics().density;
          PointerProperties[] pointerProperties =
              parsePointerPropertiesList(touch.rawPointerPropertiesList)
                  .toArray(new PointerProperties[touch.pointerCount]);
          PointerCoords[] pointerCoords =
              parsePointerCoordsList(touch.rawPointerCoords, density)
                  .toArray(new PointerCoords[touch.pointerCount]);

          if (!vdControllers.containsKey(touch.viewId)) {
            throw new IllegalStateException(
                "Sending touch to an unknown view with id: " + touch.viewId);
          }
          View view = vdControllers.get(touch.viewId).getView();

          MotionEvent event =
              MotionEvent.obtain(
                  touch.downTime.longValue(),
                  touch.eventTime.longValue(),
                  touch.action,
                  touch.pointerCount,
                  pointerProperties,
                  pointerCoords,
                  touch.metaState,
                  touch.buttonState,
                  touch.xPrecision,
                  touch.yPrecision,
                  touch.deviceId,
                  touch.edgeFlags,
                  touch.source,
                  touch.flags);

          view.dispatchTouchEvent(event);
        }

        @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
        @Override
        public void setDirection(int viewId, int direction) {
          ensureValidAndroidVersion();

          if (!validateDirection(direction)) {
            throw new IllegalStateException(
                "Trying to set unknown direction value: "
                    + direction
                    + "(view id: "
                    + viewId
                    + ")");
          }

          View view = vdControllers.get(viewId).getView();
          if (view == null) {
            throw new IllegalStateException(
                "Sending touch to an unknown view with id: " + direction);
          }

          view.setLayoutDirection(direction);
        }

        @Override
        public void clearFocus(int viewId) {
          View view = vdControllers.get(viewId).getView();
          view.clearFocus();
        }

        private void ensureValidAndroidVersion() {
          if (Build.VERSION.SDK_INT < MINIMAL_SDK) {
            throw new IllegalStateException(
                "Trying to use platform views with API "
                    + Build.VERSION.SDK_INT
                    + ", required API level is: "
                    + MINIMAL_SDK);
          }
        }
      };

  public PlatformViewsController() {
    registry = new PlatformViewRegistryImpl();
    vdControllers = new HashMap<>();
    accessibilityEventsDelegate = new AccessibilityEventsDelegate();
    contextToPlatformView = new HashMap<>();
  }

  /**
   * Attaches this platform views controller to its input and output channels.
   *
   * @param context The base context that will be passed to embedded views created by this
   *     controller. This should be the context of the Activity hosting the Flutter application.
   * @param textureRegistry The texture registry which provides the output textures into which the
   *     embedded views will be rendered.
   * @param dartExecutor The dart execution context, which is used to setup a system channel.
   */
  public void attach(
      Context context, TextureRegistry textureRegistry, @NonNull DartExecutor dartExecutor) {
    if (this.context != null) {
      throw new AssertionError(
          "A PlatformViewsController can only be attached to a single output target.\n"
              + "attach was called while the PlatformViewsController was already attached.");
    }
    this.context = context;
    this.textureRegistry = textureRegistry;
    platformViewsChannel = new PlatformViewsChannel(dartExecutor);
    platformViewsChannel.setPlatformViewsHandler(channelHandler);
  }

  /**
   * Detaches this platform views controller.
   *
   * <p>This is typically called when a Flutter applications moves to run in the background, or is
   * destroyed. After calling this the platform views controller will no longer listen to it's
   * previous messenger, and will not maintain references to the texture registry, context, and
   * messenger passed to the previous attach call.
   */
  @UiThread
  public void detach() {
    platformViewsChannel.setPlatformViewsHandler(null);
    platformViewsChannel = null;
    context = null;
    textureRegistry = null;
  }

  /**
   * This {@code PlatformViewsController} and its {@code FlutterEngine} is now attached to an
   * Android {@code View} that renders a Flutter UI.
   */
  public void attachToView(@NonNull View flutterView) {
    this.flutterView = flutterView;

    // Inform all existing platform views that they are now associated with
    // a Flutter View.
    for (VirtualDisplayController controller : vdControllers.values()) {
      controller.onFlutterViewAttached(flutterView);
    }
  }

  /**
   * This {@code PlatformViewController} and its {@code FlutterEngine} are no longer attached to an
   * Android {@code View} that renders a Flutter UI.
   *
   * <p>All platform views controlled by this {@code PlatformViewController} will be detached from
   * the previously attached {@code View}.
   */
  public void detachFromView() {
    this.flutterView = null;

    // Inform all existing platform views that they are no longer associated with
    // a Flutter View.
    for (VirtualDisplayController controller : vdControllers.values()) {
      controller.onFlutterViewDetached();
    }
  }

  @Override
  public void attachAccessibilityBridge(AccessibilityBridge accessibilityBridge) {
    accessibilityEventsDelegate.setAccessibilityBridge(accessibilityBridge);
  }

  @Override
  public void detachAccessibiltyBridge() {
    accessibilityEventsDelegate.setAccessibilityBridge(null);
  }

  /**
   * Attaches this controller to a text input plugin.
   *
   * <p>While a text input plugin is available, the platform views controller interacts with it to
   * facilitate delegation of text input connections to platform views.
   *
   * <p>A platform views controller should be attached to a text input plugin whenever it is
   * possible for the Flutter framework to receive text input.
   */
  public void attachTextInputPlugin(TextInputPlugin textInputPlugin) {
    this.textInputPlugin = textInputPlugin;
  }

  /** Detaches this controller from the currently attached text input plugin. */
  public void detachTextInputPlugin() {
    textInputPlugin = null;
  }

  /**
   * Returns true if Flutter should perform input connection proxying for the view.
   *
   * <p>If the view is a platform view managed by this platform views controller returns true. Else
   * if the view was created in a platform view's VD, delegates the decision to the platform view's
   * {@link View#checkInputConnectionProxy(View)} method. Else returns false.
   */
  public boolean checkInputConnectionProxy(View view) {
    if (!contextToPlatformView.containsKey(view.getContext())) {
      return false;
    }
    View platformView = contextToPlatformView.get(view.getContext());
    if (platformView == view) {
      return true;
    }
    return platformView.checkInputConnectionProxy(view);
  }

  public PlatformViewRegistry getRegistry() {
    return registry;
  }

  /**
   * Invoked when the {@link io.flutter.embedding.engine.FlutterEngine} that owns this {@link
   * PlatformViewsController} attaches to JNI.
   */
  public void onAttachedToJNI() {
    // Currently no action needs to be taken after JNI attachment.
  }

  /**
   * Invoked when the {@link io.flutter.embedding.engine.FlutterEngine} that owns this {@link
   * PlatformViewsController} detaches from JNI.
   */
  public void onDetachedFromJNI() {
    // Dispose all virtual displays so that any future updates to textures will not be
    // propagated to the native peer.
    flushAllViews();
  }

  public void onPreEngineRestart() {
    flushAllViews();
  }

  @Override
  public View getPlatformViewById(Integer id) {
    VirtualDisplayController controller = vdControllers.get(id);
    if (controller == null) {
      return null;
    }
    return controller.getView();
  }

  private void lockInputConnection(@NonNull VirtualDisplayController controller) {
    if (textInputPlugin == null) {
      return;
    }
    textInputPlugin.lockPlatformViewInputConnection();
    controller.onInputConnectionLocked();
  }

  private void unlockInputConnection(@NonNull VirtualDisplayController controller) {
    if (textInputPlugin == null) {
      return;
    }
    textInputPlugin.unlockPlatformViewInputConnection();
    controller.onInputConnectionUnlocked();
  }

  private static boolean validateDirection(int direction) {
    return direction == View.LAYOUT_DIRECTION_LTR || direction == View.LAYOUT_DIRECTION_RTL;
  }

  @SuppressWarnings("unchecked")
  private static List<PointerProperties> parsePointerPropertiesList(Object rawPropertiesList) {
    List<Object> rawProperties = (List<Object>) rawPropertiesList;
    List<PointerProperties> pointerProperties = new ArrayList<>();
    for (Object o : rawProperties) {
      pointerProperties.add(parsePointerProperties(o));
    }
    return pointerProperties;
  }

  @SuppressWarnings("unchecked")
  private static PointerProperties parsePointerProperties(Object rawProperties) {
    List<Object> propertiesList = (List<Object>) rawProperties;
    PointerProperties properties = new MotionEvent.PointerProperties();
    properties.id = (int) propertiesList.get(0);
    properties.toolType = (int) propertiesList.get(1);
    return properties;
  }

  @SuppressWarnings("unchecked")
  private static List<PointerCoords> parsePointerCoordsList(Object rawCoordsList, float density) {
    List<Object> rawCoords = (List<Object>) rawCoordsList;
    List<PointerCoords> pointerCoords = new ArrayList<>();
    for (Object o : rawCoords) {
      pointerCoords.add(parsePointerCoords(o, density));
    }
    return pointerCoords;
  }

  @SuppressWarnings("unchecked")
  private static PointerCoords parsePointerCoords(Object rawCoords, float density) {
    List<Object> coordsList = (List<Object>) rawCoords;
    PointerCoords coords = new MotionEvent.PointerCoords();
    coords.orientation = (float) (double) coordsList.get(0);
    coords.pressure = (float) (double) coordsList.get(1);
    coords.size = (float) (double) coordsList.get(2);
    coords.toolMajor = (float) (double) coordsList.get(3) * density;
    coords.toolMinor = (float) (double) coordsList.get(4) * density;
    coords.touchMajor = (float) (double) coordsList.get(5) * density;
    coords.touchMinor = (float) (double) coordsList.get(6) * density;
    coords.x = (float) (double) coordsList.get(7) * density;
    coords.y = (float) (double) coordsList.get(8) * density;
    return coords;
  }

  // Creating a VirtualDisplay larger than the size of the device screen size
  // could cause the device to restart: https://github.com/flutter/flutter/issues/28978
  private void validateVirtualDisplayDimensions(int width, int height) {
    DisplayMetrics metrics = context.getResources().getDisplayMetrics();
    if (height > metrics.heightPixels || width > metrics.widthPixels) {
      String message =
          "Creating a virtual display of size: "
              + "["
              + width
              + ", "
              + height
              + "] may result in problems"
              + "(https://github.com/flutter/flutter/issues/2897)."
              + "It is larger than the device screen size: "
              + "["
              + metrics.widthPixels
              + ", "
              + metrics.heightPixels
              + "].";
      Log.w(TAG, message);
    }
  }

  private int toPhysicalPixels(double logicalPixels) {
    float density = context.getResources().getDisplayMetrics().density;
    return (int) Math.round(logicalPixels * density);
  }

  private void flushAllViews() {
    for (VirtualDisplayController controller : vdControllers.values()) {
      controller.dispose();
    }
    vdControllers.clear();
  }
}
